本文〈從 C++ 類別到統一知識語言的想法〉(https://ithelp.ithome.com.tw/articles/10400528)
一開始就提出了 CSCall Project 的程式庫計劃(https://sourceforge.net/projects/cscall/),
但卻一直沒有談及內容的細節,這是有原因的。根據作者的經驗,除了學院派外,
搞實際問題的人很少會想聽又一篇看似因果嚴謹的「論文式方法論」文章——大家每天都在
寫 code,每個 C++ 工程師對於「程式該怎麼寫才是最好」都有自己一套堅不可摧的想法。
直接丟概念,結果往往就像現況:每個人有自己的 style,很難產生共鳴、交流有障礙
(所以 Linus Torvalds 對於整合各方 C++ 原始碼的抱怨,是有道理的)。
因此,考慮到工程實務的特性,我決定把這套思考拉回最貼近大家的工程痛點來談。
CSCall Project 的基礎是 ClassGuidelines。ClassGuidelines 的內容主要是規範各種
OOP程序概念上通用的 class 的 reset rule 與錯誤處理原則(看似簡單,但遵守與否的
結果卻有巨大差異)。而我在貼出前兩篇文章後,進一步將這些實務經驗統合出一個核心
概念:降低理解成本。
可能很少人注意到:一個人一天可以在辦公室打出上萬行的程式碼(AI 更不用說),但其實
'寫程式'的大部分時間都耗在「理解」上,所以:
若寫一個新功能若需要半天,看懂一個陌生模組,可能需要三天。修正一個 bug,真正花
時間的往往不是修改最後那一行,而是找出它為什麼會錯,以及事後的修改是否會影響到
其它地方,還有重用性等等問題。
近幾年 AI 大幅降低了寫程式的成本,但這並不會讓系統變得更容易理解。相反地,如果
專案沒有一致的設計原則、狀態管理方式或命名規則,AI 只會以更恐怖的速度產生出大量
難以維護的程式碼。程式碼的來源改變了,但「理解程式」的需求不僅沒有消失,反而變得
更加迫切。程式的來源改變了,但理解程式的需求並沒有消失,同樣會造成程式碼的管理
問題及日後的麻煩。
因此,從「降低理解成本」的角度重新檢視 ClassGuidelines,可能看似嚴謹的規定就
有了合理的解釋。例如在錯誤處理上,C++ 標準庫(std)大量依賴 Exception,但其邏輯
本質其實存在很大的問題——它將控制流程隱式化,Stack unwinding 的傳遞過程往往帶來
不必要的複雜性與語意誤導,並降低函式所能傳遞的資訊。因此 ClassGuidelines 選擇回歸
最基本的手法:回傳 Errno 並強制加上 warn_unused_result。這種顯式的錯誤控制
(Explicit Error Control),雖然寫起來沒那麼「語法糖」,卻能確保狀態轉移的單向性
與確定性,大幅降低後續推理與除錯的成本。同樣地,Class 的 Reset Rule 與物件狀態
一致性的要求,概念上並不複雜。但不同團隊甚至不同工程師,對資源管理與狀態生命週期
的表示方式卻天差地遠。如果程式能有一套極度一致、低成本的表示方式,理解成本就能
顯著下降。另方面, AI 的出現,也讓此理解成本的問題變得更加重要。以前,程式主要
由人撰寫;現在,越來越多程式是由 AI 協助產生。如果沒有共同的基本設計原則,AI
只會更快生成更多程式碼,而理解成本也更難同步下降。
若更進一步思考:如果程式可以建立一套一致的理解方式,那麼數學、演算法、邏輯,
甚至其他形式的知識(解題所需),是否也能建立共同的表示與操作方式?這條思路,才是
我真正想探索的問題——它從 ClassGuidelines 一步步發展成 BitMachine、BML,以及我
目前仍在整理中的 CTT2BM。
現在回頭來看,ClassGuidelines 至BML 其實是起源於一個很實際的工程問題:如何降低
理解的成本。我只是把同一個問題,逐漸從程式設計延伸到演算法、數學,以及其它形式
的知識(至少,在我的觀點中,它仍然是一個實務的工程問題)。
以下是 ClassGuidelines 的中譯文(因字詞觀念上的落差,我覺得原文還是用英文寫較好,
這裡的中譯僅供對照參考。英文版可於https://sourceforge.net/projects/cscall/files/MisFiles/ClassGuidelines_en.txt/download 下載):
本檔為 Rationale.txt 中類別準則的更新版。
本準則聚焦於 C++ 類別(概念)設計的物件導向方式。
Name binding(名稱綁定)
(可能存在匿名物件。除了原子(atomic)可被參照外,名稱必須指向某個具體
的東西)
Size binding(大小綁定)
配置記憶體所需的資訊
Address binding(位址綁定)
處於此階段的物件稱為隨機狀態(random state)。
Semantics binding(語意綁定)
物件進入對其所占空間及內容負責的狀態(responsible state)。
(外部資源可能在此語意階段被關聯進來)
反向(Reversal)即為物件的解構(destruct)過程
附註:可被隨機覆寫(overwritten)的物件類別,在本函式庫中稱為
discardable(可拋棄的)。這類物件通常沒有使用者自定義的解構子。
----- 生命週期觀點所得出的結果,即為類別成員的基本規則
建構子 Constructor(..):
建構子的參數列表直接對應到問題領域上的一個笛卡兒積(Cartesian
product)——ctor(A, B, C) 定義的是 A × B × C 這個空間。換言之,一個
類別是由其建構子所接受的參數所定義的。
建構子使物件從隨機狀態(random state)進入負責狀態(responsible state)。
解構子 Destructor:
已解構的物件在 C++ 語言中不再被視為存在(參照 [12.4.14])。由於對同一
物件解構兩次會導致未定義行為(undefined behavior),解構子必須確保
「解構一次」就已足夠。
物件解構揭示了一個常被忽略的機制:堆疊展開(stack unwinding)本身就是
一種隱含的「回滾(rollback)」。正因為它是隱含的,其失敗很容易被忽略——
而且這個失敗會沿著整條展開路徑被放大。這正是解構子必須成功的原因。
附註:C++ 語言強制規定了「解構子必須成功」的語意。
Const 成員函式:
Const 函式成員常被稱為屬性(property)或特性(attribute)成員。一般而
言,這類成員必須存在最低限度的集合。這些成員通常複雜度為 O(1),並提供
該類別設計得宜的基本證明。
Reset(..) 成員:
Reset 成員使物件從負責狀態(responsible state)回到隨機狀態(random
state),然後執行對應參數的建構子所做的事。因此,若存在某個 reset
成員,則對應參數的建構子也必然存在。
建構、解構與重置(reset),就概念而言,都是由生命週期各步驟(及其對稱
的反向操作)所組成的複合操作。實務上,建構子可以單純呼叫 reset() 的
實作來完成——理論上的順序不必被字面遵循,只要最終的後置條件
(postcondition)相同即可。
本函式庫要求物件必須永遠維持在「有效(valid)」的狀態。預設狀態
(default state)是保證一個已建構物件——無論其處於哪種有效狀態——
都能被成功解構的最簡單檢查點,因此 reset() 的後置條件被要求為預設
狀態。請注意,reset 成員通常會存在,但並非必要。例如,若類別包含
const 或參照(reference)資料成員,則 reset 將難以實作。
Move Constructor(移動建構子):
設計此機制的動機,來自於避免在包含已配置資源的類別,以及動態陣列中
物件搬移(movement)時,所產生的昂貴且不必要的複製操作。實務經驗顯示
這樣的動機基本上只需要一個 move constructor。本函式庫目前(暫時)
使用 enum ByMove_t { ByMove } 作為 move constructor 的簽名,其定義
為將來源(source)所參照的物件移動到 'this' 所指向的位址(來源物件視
為不再存在)。原因在於這樣的操作在概念上是基本(elementary)的,因此
得以定義出一個不會拋出例外(no-throw)的 swap 函式範本(附註:實作上
需要一個緩衝區型別,例如 type_store,但這是另一個議題)。
許多其他函式庫並不包含 move constructor。逐位元複製(bit-wise copy,
即 memcpy)大致上多能勝任,例如目前為止 Qt 函式庫的 QString。只需確保
已被移動之物件的解構子不會被呼叫即可。
附註:C++11 引入了右值參照(rv-reference),並定義了另一個名稱
'std::move';因此一個接受右值參照引數的建構子便被稱為 move
constructor。本函式庫決定繼續使用這個現在已與標準用語有些混淆的
move constructor 概念繼續開發——單純是因為 ByMove 相對而言更為基本
(elementary)。這些理由多數可歸結為「輕量(light)」——輕量指的是低
負擔、易於推理、易於變更。ByMove 只需要一個語言表層的符號與一個儲存
範本;它不需要 C++ 核心語言提供任何東西。這也延續了 C++ 早期的一項
原則:C++ 力求成為「最底層的高階語言」,不留下需要更低階實作的空間。
另一個理由是,對作者而言,使用它的效益與成本比看起來並不划算;但願本
函式庫仍能存續下去。
可存取的成員賦予物件語意。但本準則僅能涵蓋一般性的情況。
一般性規則:一個類別成員的語意應當一致地圍繞著一件事——如其建構子所
暗示的那樣——成員的集合應在該件事上是封閉(closed)的——並且,在
可能的情況下,這種一致性應當是可驗證的。
令 T 為某個給定類別,以下定義成員名稱與其對應的功能。
T() 預設建構子。如此建構出的物件稱為處於預設狀態(default
state),即所謂的預設物件(default object)。
拋出例外時的後置條件:物件不存在。
附註:若「物件」可意味著「無實際內容」,則預設物件最好被設計
成能保證安全解構(甚至更好的是,可用 memcpy 複製)。
T(const T&) 將 *this 建構為與來源物件相同的狀態。
請注意,雖然複製建構出的物件應與來源物件「相同(identical)」,
但這並非 C++ 標準語言所要求的。
T(T&, ByMove_t) 將來源物件搬移到 'this' 所指的位址。
此成員其實稱不上是建構子,因為並沒有新東西被建立。實務上,
move ctor 只在 malloc 配置的陣列以及實作 swap 時才需要。
T(const TT&) 轉換建構子(Conversion ctor)隱含地轉換概念。若
sizeof(T)!=sizeof(TT),且「T 是 TT,TT 也是 T」這句話讀起來
不通順,則應宣告為 'explicit'。同樣的顧慮也適用於轉換運算子
(operator TT()):基於相同理由,應考慮將其宣告為 explicit。
Errno reset(..)
將物件重新建構為對應參數之建構子所產生的狀態。
範例:reset 成員的行為應與對應參數的建構子完全相同,如下例
所示:
Errno T::reset(Type1 a, Type2 b) try {
T obj(a,b);
swap(*this,obj);
return Ok;
}
catch(const Errno& e) { // 可能有許多這樣的 catch 子句。
return e; // 此處為示範而簡化。
};
附註:此規則很重要。實務上在某些情況下可能會想做得稍有不同。
但不——這樣可能會存在隱藏且難以發現的臭蟲(bugs)(也許
應該重新考慮該類別的概念、介面設計或命名)。
附註:對於 reset()(無引數版本)而言,無論回傳何種 Errno 或
堆疊是否曾展開(unwind),物件的後置條件永遠是預設狀態。
~T 解構物件
後置條件:物件不存在
bool is_default() const
若物件等同於預設建構出的物件,則回傳 true(即
a.is_default() <==> a==T(),若有定義 operator== 的話)。
bool operator==(const T& rhs) const
此函式回傳 true,若且唯若在相同條件下,使用任何非 private
成員皆無法區分 *this 與 rhs,除非另有明確規定。
附註:物件的等價性(equivalence)不包含其中繼資訊(meta info.)
及自身位址的等價性。
T& operator=(const T&)
將物件重新建構為複製建構子所建構出的狀態(與
reset(const T&) 相同,除了回傳型別與拋出例外的方式不同)。
void swap(&T)
交換 *this 與引數所指物件的狀態。
以 'a.swap(b)'(可交換,commutative)為例,a 的狀態被設為 b
的狀態,而 a 先前的狀態則變成 b 的狀態。b 亦然。
附註:此操作不涉及建構與解構的語意。
Reply 類別專屬的拋出類別(throw class),繼承自 Errno,用以協助辨識
拋出的來源。
_.. 特定實作成員的前綴,規則不同……等等。
c_.. C 相關事物的前綴。此類成員的規則可能有所不同。
wy_.. 內部成員的前綴(用於測試等)。
wrd(const T&)(或稱 notation(..))
此函式的用意是將引數物件轉換為文字,原則上可由此文字重建出
一個等價的物件(以 operator== 驗證)。如何達成此規格,對本
函式庫而言仍是一個開放性的問題;尚未被視為已解決。
附註:此函式並非強制性的。此文字也可能包含外部事件與子類別
之間互動的描述。將此文字印出並與類別文件對照,可作為一
種基本的、人工可核對的證明,證明實作與其文件所述行為
相符。
本函式庫作為一個基礎性(elementary)的函式庫,必須讓所提供函式(多數
原本來自底層 clib 函式)所回報的任何錯誤,都能被其呼叫者妥善處理,以完成
所請求的功能。但是,拋出錯誤(throwing error)會遺失上下文資訊(參見
附錄 A)。函式(以及建構子/解構子等)應當捕捉所有令人困惑的拋出錯誤,
並轉換為其自身所負責的錯誤。要達成此要求存在困難,尤其是對範本
(template)類別而言。因此,本函式庫應盡量減少使用範本。
附註:實作也應考慮其他事項,例如執行緒取消(thread cancellation)與
訊號安全性(signal-safety)。
附註:一般規則是,函式應以回傳 Errno 的方式回報錯誤,而非以拋出例外的
方式(參見〈回傳錯誤與錯誤檢查〉及附錄 B)。拋出例外是此規則的
例外情況,適用於兩種不同的情境:
由建構子或運算子多載(operator overload)所發出的錯誤,應為
Reply 類別。在更為嚴重的情況下,可拋出其他型別,這將被視為一種
「軟性(soft)」斷言失敗(assertion failure),因為 catch 端的
程式碼可以不同意堆疊展開的傳遞方式。
未被處理的錯誤(分支點資訊)永遠是一個隱藏的、等著發生的「未定義行為
(UB)」。準則是:務必檢查錯誤(ALWAYS CHECK THE ERROR)。此準則也延伸
至 API,因此,回傳 Errno 的函式應加上
"attribute((warn_unused_result))"。但一如往常,例外情況總是存在。
從函式使用的角度來看:錯誤檢查的一般顧慮,可能在於原始碼的複雜度與可讀
性。就本準則的觀點而言,若不檢查與處理錯誤,那麼對程式碼的評價(可讀性、
美感等)就是片面的判斷,因為錯誤檢查與處理本就是完整程式(或函式)的一
部分。API 總是可以把「不受歡迎」的部分丟到別處,但機器看得見一切。
最低限度的「檢查並拋出」(check-and-throw)模式,勝過完全忽略;這樣的
寫法(throw)相當於一種混合了廉價與較佳品質的 assert(3)。
實務證明,錯誤檢查實際上能顯著減少軟體開發與維護的時間。
錯誤回報後,類別的狀態是由實作自行定義(implementation defined)的。
由來源資訊寫入某處,正是程式所做的事。但是,當來源與目的物件重疊時
(當物件是以指標或參照表示時,這是可能發生的),事情就變得棘手,例如:
t.reset(t);
str.reset(str_seg); // str_seg 指向 str 內部的一段字串。
str.insert(3,str_seg);
在 C++ 中,若來源引數為 const,則 const 的承諾至少會被打破。這類表達式
的語意可歸類為未定義(undefined),而這幾乎是所有夠強大的語言都具有的
一項「特色」。
本準則目前對於這類自身操作(self-ops)該如何處理,尚無強烈立場(就像
除以零的錯誤一樣)。實作可以檢查自身操作(一般而言不易做到)並回傳
ELOOP。由於此類意圖多半假設引數是以傳值(by value)方式傳遞,因此可能
需要繞道處理,儘管如此,一個理論上的臭蟲(bug)仍可能因此被隱藏起來。
私有區段中的資料成員,原則上最好使用 POD 型別,因為這是最快速且最強大
的實作方式。
若某個類別的大小及記憶體消耗,是另一種實作選擇的兩倍,那麼它就應該要快
上兩倍。速度與大小是同一件事,但這個比例可以依使用頻率而有所妥協。
原則上,一個函式應該鎖定(lock)其所處理的所有物件,以防止競爭條件
(race condition)。但這並不可行;因此,一個函式應當保持簡單、專注於
一件事,並且不需要鎖定任何東西。
本函式庫的存在,是為了將常見的 C 函式與系統呼叫(system calls),包裝
成一致的、物件導向的介面——而不是企圖取代所有底層的 API。與
Clib/系統呼叫混合使用,對應用程式而言應當是可行的,因為要求高的應用程式
通常會自行建構其核心物件(類別),無論它們使用哪些函式庫。
若某個類別明確地包裝了某個 C 結構(或其他東西),該類別應考慮公開所
包裝的結構,基本原因是底層的 C 事物本身也仍在發展之中。還有更多其他
理由。
拋出的錯誤,可能與被呼叫的函式並無關聯。
int read_integer(size_t maxlen) {
int v;
if(maxlen>10) {
throw Invalid_Argument;
}
// 讀取字串並轉換為整數 v(可能拋出 Invalid_Argument)
return v;
}
void f(int count) {
int v;
if(count<0) {
throw Invalid_Argument;
}
for(int i=0; i<count; ++i) {
try {
v= read_integer(8);
}
catch (Invalid_Argument) {
// 這未必是在 read_integer(..) 中所拋出的 Invalid_Argument。
// 基於此假設所做的判斷是錯誤的。
}
}
}
打個比方,拋出錯誤就像是使用執行緒區域(thread local)的 errno 來傳遞
錯誤回報,並強制規定:若堆疊中的函式未檢查該 errno,堆疊便會展開
(unwind,如同 return 一般)。因此,一般情況下,呼叫端函式無從得知在
已展開的路徑中,究竟是哪一個函式應為該 errno 負責。
執行程式碼的重複使用有兩種型態:作為函式,或作為巨集(C++ 範本可視為一種
進階的巨集)。一個函式的出口點(我指的是機器碼、或組合語言的流程圖),
傳統上被簡化為單一一點(即回傳位址),做法是將分支點所攜帶的離開資訊,
編碼進一個回傳物件之中——這就是原始程式碼的基本樣貌(更進一步的本質可能
更為有趣)。
於是,便誕生了攜帶分支點資訊的物件,以及將其編碼與解碼所需付出的空間與
時間成本。「錯誤(error)」或「例外(exception)」是人類的想法,它們
不過是用於分支的資訊/機制而已。
模組之間的控制流程也是類似的道理,只是有更多耍花招的空間。
因此,本函式庫將回傳 Errno 視為一種基本手法。
本函式庫拒絕 C++ 那種將語法上的便利性置於語意清晰性之上的趨勢。我們偏好
一種「最底層的高階(lowest-level high-level)」取向。一個類別是狀態轉換
的一份合約(contract);若該語言中「強大」的特性(範本、複雜的繼承、隱式
的移動)模糊了這份合約,或使這個狀態機更難以推理,那麼這些特性便應明確
地被勸阻使用。
從大型 C++ 專案的觀察可知,過度強調表達力,往往導致原始碼本質上難以管理。
我們主張,若一個類別無法提供這一套完整的成員,首先該被懷疑的應是該類別
背後的概念,而不是規則本身。目標是寫出易於推理、易於變更的程式碼,而不是
表面上「快速寫出來」的短程式碼。
具表達力的語法是一把雙面刃。盲目的表達力,能讓一個邏輯上錯誤的想法,和一
個正確的想法一樣容易表達出來——而且同樣具有吸引力。語法本身對「真理」
沒有立場;它只是降低了把某件事寫下來的成本,無論那件事本身是否正確。若一
個想法的正確性尚未先被確立,表達力強的語法只會加速這個錯誤在程式碼庫中
擴散,並使得產出的程式碼更難與經過妥善推理的程式碼區分開來。因此,我們
堅持:一項設計必須先以其自身的立場——作為狀態與行為的模型——被驗證過,
之後才能施加語法上的糖衣,使其書寫起來更為愉快。
"Mind in Motion: How Action Shapes Thought" [Barbara Tversky]
https://www.amazon.com/Mind-Motion-Action-Shapes-Thought/dp/046509306X
心智實體(mental entity)與外部實體(external entity)之間,或許存在著
一種一對一(1-1)的對應關係。
----------- End of ClassGuidelines(ClassGuidelines 結束)